# Vanilla module
# Tested against versions 1.1.4 and 1.1.10

import forumapi
from deps.Form import Form
from deps.PageRequest import PageRequest
import re
import urllib.parse

NotImplementedException = forumapi.NotImplementedException

__list__ = {
	"Post": "Comment",
	"Thread": "Discussion",
	"Forum": "Category",
	"PreparedPost": "PreparedComment",
}

## Utilities ##

def get_number_of_pages(soup_page):
	"""Given a `BeautifulSoup`, searches for a page navigation control and
	returns the number of pages it indicates are available. If no such
	control can be found, this function returns `1`.
	
	If multiple page controls with different values are found on a page,
	the result of the first control is returned."""
	
	list_soup = soup_page.find("ol", "PageList")
	if list_soup:
		return(int(list_soup.findAll("li")[-2].string))
	
	return(1)

_id_pattern = re.compile(r"(\d+)")
def get_id_from_link(link_soup):
	"""Given a `BeautifulSoup` of a link, returns the first number that occurs
	in that link's "href" attribute."""
	
	m = _id_pattern.search(link_soup["href"])
	if m:
		return int(m.group(1))
	
	return(None)

class Session(forumapi.Session):
	"""Handles actions that are to be taken with the site, such as logging in
	and posting."""
	
	def __init__(self, site, *args, cookies=None, **kw):
		"""Creates cookies.
		
		If this is passed parameters, the following sessions can be
		started, depending on the number of parameters:
		  2 = session from userid and password hash
		  1 = session from session hash
		  0 = guest session
		
		If this is passed the `cookies` param, they will be used to initialize
		the session. Cookies are not checked for validity."""
		
		super().__init__(site, **kw)
		
		if cookies:
			self.set_cookies(cookies)
		elif args:
			if len(args) == 0:
				self.guest = True
			elif len(args) == 1:
				self.set_cookies({"PHPSESSID": args[0]})
			elif len(args) == 2:
				self.set_cookies({
					site.cookie_prefix + "cookieone": args[0],
					site.cookie_prefix + "cookietwo": args[1],
				})
				self.user = User(self, id=args[0])
			else:
				raise(forumapi.SessionError("Invalid number of parameters to instatiate user."))
	
	@classmethod
	def from_login(cls, site, username, password, kw):
		"""Logs into the site using a username and password, returning an
		authenticated session.
		
		Note that the password is sent as plaintext."""
		
		post_data = {
			"PostBackAction": "SignIn",
			"ReturnUrl": "",
			"Username": username,
			"Password": password,
			"RememberMe": "1",
		}
		
		url = site.url + "people.php"
		
		response = PageRequest(url, post_data=post_data, encoding=site.encoding)
		
		cookies = forumapi.get_cookies(response)
		if not cookies:
			# Probably a login error
			raise(forumapi.SessionError("No cookies obtained to instantiate a session."))
		
		session = cls(site, cookies=cookies, **kw)
		if session.user:
			session.user._name = username
		else:
			session.user = User(session, name=username)
		
		return(session)
	
	def get_search_results(self, special=None, pages=1, *a, **kw):
		if pages != "all" and pages < 1:
			raise(ValueError("Number of pages must be greater than zero."))
		
		if special:
			raise(NotImplementedException("Vanilla doesn't have special searches."))
		else:
			search_data = {
				"PostBackAction": "search.php",
				"Advanced": "1",
				"Type": "Discussions",
			}
			
			if len(a) > 0:
				search_data["Keywords"] = " ".join(a)
			
			if user in kw:
				search_data["AuthUsername"] = User.obj(self, kw["user"]).name
			
			if "category" in kw:
				search_data["Categories"] = Category.obj(self, kw["category"]).name
			
			search_page = self.get_page("search.php", method="get", post_data=search_data)
		
		return(self.get_discussions(search_page, pages=pages))
	
	def get_discussions(self, response, pages=1, category=None):
		if pages != "all" and pages < 1:
			raise(ValueError("Number of pages must be greater than zero."))
		
		# Get the path to be reused for next pages, remove the slash
		url = urllib.parse.urlparse(response.response.geturl())
		path = url.path[1:]
		query = urllib.parse.parse_qs(url.query)
		
		page_soup = self.soup(response)
		
		if pages != 1:
			all_pages = get_number_of_pages(page_soup)
			
			if pages == "all":
				pages = all_pages
			else:
				pages = min(pages, all_pages)
		
		discussions = []
		
		for page in range(1, pages + 1):
			if page != 1:
				query["page"] = page
				response = self.get_page(path, post_data=query, method="get")
				page_soup = self.soup(response)
			
			list_soup = page_soup.find("ol", id="Discussions")
			if not list_soup:
				return(threads)
			
			for discussion in list_soup.findAll("li", recursive=False):
				attrs = dict(discussion.attrs)
				
				discussion_id = attrs["id"][11:]
				
				closed = False
				sticky = False
				classes = attrs["class"].split()
				if "Closed" in classes:
					closed = True
				if "Sticky" in classes:
					sticky = True
				
				items = discussion.findAll("li")
				
				topic = str(items[1].find("a").string)
				
				category_link = items[2].find("a")
				category_id = get_id_from_link(category_link)
				category_name = str(category_link.string)
				
				category = Category(self, id=category_id, name=category_name)
				
				first_user_link = items[3].findAll("a")[-1]
				
				first_user_id = get_id_from_link(first_user_link)
				first_user_name = str(first_user_link.string)
				
				first_user = User(self, id=first_user_id, name=first_user_name)
				
				first_comment = Comment(self, id=None, user=first_user)
				
				comments = int(items[4].contents[2].strip())
				
				latest_links = items[5].findAll("a")
				
				latest_item_id = int(re.search("Item_(\d+)$", latest_links[0]["href"]).group(1))
				
				latest_user_id = get_id_from_link(latest_links[1])
				latest_user_name = str(latest_links[1].string)
				
				latest_user = User(self, id=latest_user_id, name=latest_user_name)
				
				latest_comment = Comment(self, id=None, user=latest_user)
				
				discussion = Discussion(self, discussion_id, starter=first_user, category=category, comments=comments, first_commment=first_comment, latest_comment=latest_comment, closed=closed, sticky=sticky)
				
				discussions.append(discussion)
		
		return(discussions)
	
	get_threads = get_discussions
	
	def get_security_token(self, form_data=None, page=None, soup=None):
		"""Retrieves a form PostBack token to be used in form submits.
		
		If `form_data` is given, it is assumed to be a list of form values or
		a `Form` object. It will be used if it contains a security token.
		If `soup` is given, it is assumed to be a `BeautifulSoup` of a form.
		If `page` is given, the page will be requested, and the first form on
		the page will be used.
		If `form_data` is not useful and neither `soup` nor `page` is given,
		the identity form on "account.php" will be tested."""
		
		if self.guest:
			# Dunno if this works
			return("guest")
		elif self.security_token:
			return(self.security_token)
		elif not form_data or "FormPostBackKey" not in form_data:
			if soup is None:
				if page is None:
					page = "account.php?PostBackAction=Identity"
				
				soup = self.soup(self.get_page(page)).find("form")
			
			form_data = Form(soup)
		
		self.security_token = form_data["FormPostBackKey"]
		return(self.security_token)
	
	def prepare_comment(self, t=None):
		if not t:
			raise(ValueError("Discussion required."))
		
		discussion = None
		
		t = Discussion.obj(self, t)
		
		form_url = "comments.php?DiscussionID={0}".format(t.id)
		
		form_page = self.get_page(form_url)
		
		soup = self.soup(form_page)
		
		soup_form = soup.find("form", action=lambda url: "post" in url)
		
		if soup_form:
			post_data = Form(soup_form, session=self)
		else:
			raise(forumapi.PermissionError("Cannot find comment form."))
		
		return(PreparedComment(self, "postreply", post_data, t=t))
	
	prepare_post = prepare_comment
	
	def make_comment(self, message, t=None, *a, **kw):
		"""Creates a new post in a single step.
		
		This function needs a message body and a `t` parameter, which can either
		be an integer id or a `Discussion` object.
		
		If successful, returns a `Comment` of the newly-created comment."""
		
		post = self.prepare_comment(t=t)
		
		post.message = message
		
		return(post.submit())
	
	make_post = make_comment
	
	def make_discussion(self, message, topic, category, *a, **kw):
		"""Creates a new thread in a given forum, returning the newly-created
		thread's ID."""
		
		category = Category.obj(self, category)
		
		form_page = self.get_page("post.php")
		
		soup = self.soup(form_page)
		
		soup_form = soup.find("form", action=lambda url: "post" in url)
		
		if soup_form:
			form_data = Form(soup_form, session=self)
		else:
			raise(forumapi.PermissionError("Cannot find discussion form."))
		
		form_data["CategoryID"] = str(int(category))
		form_data["Name"] = topic
		form_data["Body"] = message
		
		posted_page = form_data.submit()
		
		response_qs = forumapi.qs_dict_from_url(posted_page.response.geturl())
		
		try:
			id = int(response_qs["DiscussionID"][0])
			
			return(Discussion(self, id))
		except Exception:
			raise(forumapi.UnknownError("I have no idea.\n\n" + repr(response_qs)))
	
	make_thread = make_discussion

class User(forumapi.User):
	"""A user. It can know the user's ID and name."""
	
	@classmethod
	def obj(cls, session, *a, **kw):
		if isinstance(a[0], cls):
			return(a[0])
		elif isinstance(a[0], int):
			return(cls(session, *a[1:], id=a[0], **kw))
		elif isinstance(a[0], str):
			return(cls(session, *a[1:], name=a[0], **kw))
		else:
			return(None)
	
	def __init__(self, session, id=None, name=None, comment=None, role=None, comments=None, email=None):
		if not (id or name):
			raise(ValueError("ID or name required."))
		
		self.session = session
		self._id = id
		self._name = name
		self.comment = comment
		
		self._role = role
		
		self._comments = None
		if comments:
			self._comments = int(comments)
		
		self._email = None
		
		self._find_instances()
	
	def _merge(self, other):
		if self._id:
			other._id = self._id
		
		if self._name:
			other._name = self._name
		
		if self._role:
			other._role = self._role
		
		if not other.comment:
			other.comment = self.comment
		
		if self._comments:
			other._comments = self._comments
		
		if self._email is not None:
			other._email = self._email
	
	@property
	def id(self):
		if self._id:
			return(self._id)
		else:
			self.get_result()
			return(self._id)
	
	@property
	def name(self):
		if self._name:
			return(self._name)
		else:
			self.get_profile()
			return(self._name)
	
	@property
	def role(self):
		if self._role:
			return(self._role)
		elif self._id:
			self.get_profile()
			return(self._role)
		else:
			self.get_result()
			return(self._role)
	
	@property
	def comments(self):
		if self._comments:
			return(self._comments)
		elif self._id:
			self.get_profile()
			return(self._comments)
		else:
			self.get_result()
			return(self._comments)
	
	@property
	def email(self):
		if self._email:
			return(self._email)
		else:
			self.get_profile()
			return(self._email)
	
	def get_result(self):
		"""Retrieves data of the user from the search page, using the user's
		name."""
		
		post_data = {
			"PostBackAction": "Search",
			"Advanced": "1",
			"Type": "Users",
			"Keywords": self.name,
		}
		
		search_page = self.session.get_page("search.php", post_data=post_data)
		
		page_soup = self.soup(search_page)
		list_soup = page_soup.find("ol", id="UserResults")
		if list_soup:
			user_soup = list_soup.find("li")
			items = user_soup.findAll("li")
			
			if not self._id:
				self._id = get_id_from_link(items[0].find("a"))
			
			if not self._role:
				self._role = items[0].contents[4].strip()
			
			if not self._comments:
				self._comments = int(items[5].contents[2].strip())
		else:
			raise(forumapi.UserError("User does not exist."))
	
	_mail_pattern = re.compile(r"\s+WriteEmail\('([^']+)', '([^']+)',")
	def get_profile(self):
		"""Retrieves data of the user from the profile page, using the id of the
		user."""
		
		profile_page = self.session.get_page("account.php?u={0}".format(self.id))
		
		page_soup = self.soup(profile_page)
		
		list_soup = page_soup.find("ul", "vcard")
		if list_soup:
			items = list_soup.findAll("li")
			
			if not self._name:
				self._name = str(items[0].find("h2").string)
			
			if not self._role:
				self._role = str(items[0].find("p").string)
			
			offset = 0
			if list_soup.find("li", "Picture"):
				offset += 1
			if list_soup.find("li", "Tagline"):
				offset += 1
			if list_soup.find("p", "fn"):
				# Real name
				offset += 1
			
			if self._email is None:
				self._email = ""
				
				email_soup = list_soup.find("p", "email")
				if email_soup:
					script_soup = email_soup.find("script")
					
					if script_soup:
						script = str(script_soup.string)
						
						m = self._mail_pattern.match(script)
						if m:
							self._email = "{1}@{0}".format(*m.groups())
			
			if not self._comments:
				self._comments = int(items[offset+6].find("p").string)
		else:
			raise(forumapi.UserError("User does not exist."))
	
	def __eq__(self, other):
		if isinstance(other, User):
			if self._id and other._id:
				return(self.id == other.id)
			else:
				return(self.name == other.name)
		elif isinstance(other, int):
			return(self.id == other)
		elif isinstance(other, str):
			return(self.name == other)
		else:
			return(False)
	
	def __ne__(self, other):
		return(not self.__eq__(other))
	
	def __str__(self):
		return(self.name)
	
	def __int__(self):
		return(self.id)
	
	def __repr__(self):
		return("<vanilla.User {self.id} \"{self.name}\">".format(self=self))

class Comment(forumapi.Post):
	"""A comments. It knows its ID and thread."""
	
	@classmethod
	def obj(cls, session, *a, **kw):
		if isinstance(a[0], cls):
			return(a[0])
		elif isinstance(a[0], int):
			return(cls(session, *a[1:], id=a[0], **kw))
		else:
			return(None)
	
	def __init__(self, session, id=None, discussion=None, content=None, user=None):
		self.session = session
		
		self._id = id
		
		self._discussion = Discussion.obj(session, discussion)
		
		self._content = content
		
		self._user = User.obj(session, user, post=self)
		
		self._find_instances()
	
	def _merge(self, other):
		if self.discussion is not None:
			other.discussion = self.discussion
		
		if self._id:
			other._id = self._id
		
		if self._content:
			other._content = self._content
			
		if self._user:
			other._user = self._user
	
	@property
	def id(self):
		if self._id:
			return(self._id)
		else:
			# Try discussion and otherwise do a search? On what?
			raise(NotImplementedException("Cannot retrieve item ID."))
	
	@property
	def discussion(self):
		if self._discussion:
			return(self._discussion)
		else:
			raise(NotImplementedException("Cannot retrieve discussion."))
	
	@property
	def content(self):
		if self._content:
			return(self._content)
		else:
			# Look up in discussion?
			raise(NotImplementedException("Cannot retrieve comment content."))
	
	@property
	def user(self):
		if self._user:
			return(self._user)
		else:
			raise(NotImplementedException("Cannot retrieve comment user."))
	
	def edit(self, message, append=False, *a, **kw):
		"""Edits the comment in a single step."""
		
		post = self.prepare_edit()
		
		if append:
			post.message += "\n\n" + message
		else:
			post.message = message
		
		post.submit()
	
	def prepare_edit(self):
		identifier = "p", self.id
		
		form_page = self.get_page("post.php?CommentID={0}".format(self.id)
		
		soup = self.soup(form_page)
		
		soup_form = soup.find("form", action=lambda url: "post" in url)
		
		if soup_form:
			form_data = Form(soup_form, session=self)
		else:
			raise(forumapi.PermissionError("Cannot find comment form."))
		
		return(PreparedComment(self.session, "editpost", identifier, form_data, p=self))
	
	def __eq__(self, other):
		if isinstance(other, Comment):
			return(self.id == other.id)
		elif isinstance(other, int):
			return(self.comment_id == other)
		else:
			return(False)
	
	def __ne__(self, other):
		return(not self.__eq__(other))
	
	def __int__(self):
		return(self.id)
	
	def __repr__(self):
		return("<vanilla.Comment {self.id}{0} in d{self.discussion.id}>".format(" by {self.user.name}".format(self=self) if self._user else "", self=self))

class Discussion(forumapi.Thread):
	"""A discussion. It knows its ID, a latest comment, the topic, and the 
	category it's in. It can also know the number of posts."""
	
	@classmethod
	def obj(cls, session, *a, **kw):
		if isinstance(a[0], cls):
			return(a[0])
		elif isinstance(a[0], int):
			return(cls(session, *a[1:], id=a[0], **kw))
		elif isinstance(a[0], str):
			return(cls(session, *a[1:], title=a[0], **kw))
		else:
			return(None)
	
	def __init__(self, session, id=None, first_comment=None, latest_comment=None, topic=None, category=None, comments=None, starter=None, sticky=None, closed=False):
		if not (id or title):
			raise(ValueError("ID or topic required."))
		
		self.session = session
		self._id = id
		
		self._topic = topic
		
		self._category = Category.obj(session, category)
		
		self._comments = comments
		
		self._first_comment = Comment.obj(session, first_comment, discussion=self)
		self._latest_comment = Comment.obj(session, latest_comment, discussion=self)
		
		self._sticky = sticky
		self._closed = closed
		
		self._find_instances()
	
	def _merge(self, other):
		if self._id:
			other._id = self._id
		
		if self._topic:
			other._topic = self._topic
		
		if self._category:
			other._category = self._category
		
		if self._comments is not None:
			other._comments = self._comments
		
		if self._first_comment is not None:
			other._first_comment = self._first_comment
		
		if self._latest_comment is not None:
			other._latest_comment = self._latest_comment
		
		if self._sticky is not None:
			other._sticky = self._sticky
		
		if self._closed is not None:
			other._closed = self._closed
	
	@property
	def id(self):
		if self._id:
			return(self._id)
		else:
			# Topic search?
			raise(NotImplementedException("Cannot retrieve discussion ID."))
	
	@property
	def topic(self):
		if self._topic:
			return(self._topic)
		else:
			raise(NotImplementedException("Cannot retrieve discussion topic."))
	
	@property
	def category(self):
		if self._category:
			return(self._category)
		else:
			raise(NotImplementedException("Cannot retrieve latest comment."))
	
	@property
	def first_comment(self):
		if self._first_comment is not None:
			return(self._first_comment)
		else:
			raise(NotImplementedException("Cannot retrieve first comment."))
	
	@property
	def latest_comment(self):
		if self._latest_comment is not None:
			return(self._latest_comment)
		else:
			raise(NotImplementedException("Cannot retrieve latest comment."))
	
	@property
	def sticky(self):
		if self._sticky is not None:
			return(self._sticky)
		else:
			raise(NotImplementedException("Cannot retrieve stickyness."))
	
	@property
	def closed(self):
		if self._closed:
			return(self._closed)
		else:
			raise(NotImplementedException("Cannot retrieve closedness."))
	
	def __eq__(self, other):
		if isinstance(other, Discussion):
			if self._topic and other._topic:
				# Not really exact if there are multiple threads with same name
				return(self.category == other.category and self.topic == other.topic)
			else:
				return(self.id == other.id)
		elif isinstance(other, int):
			return(self.id == other)
		else:
			return(False)
	
	def __ne__(self, other):
		return(not self.__eq__(other))
	
	def __str__(self):
		return(self.title)
	
	def __int__(self):
		return(self.id)
	
	def __repr__(self):
		return("<vanilla.Discussion {self.id} \"{self.topic}\" in c{self.category.id}>".format(self=self))

class Category(forumapi.Forum):
	"""A category. It knows its ID, and might know its name and discussions."""
	
	@classmethod
	def obj(cls, session, *a, **kw):
		if isinstance(a[0], cls):
			return(a[0])
		elif isinstance(a[0], int):
			return(cls(session, *a[1:], id=a[0], **kw))
		elif isinstance(a[0], str):
			return(cls(session, *a[1:], name=a[0], **kw))
		else:
			return(None)
	
	def __init__(self, session, id=None, name=None, description=None, discussions=[]):
		if not (id or name):
			raise(ValueError("ID or name required."))
		
		self.session = session
		self._id = id
		self._name = name
		self._description = description
		self._discussions = discussions
		
		self._find_instances()
	
	def _merge(self, other):
		if self._id:
			other._id = self._id
		if self._name:
			other._name = self._name
		
		for discussion in self._discussions:
			if thread not in other._discussions:
				other._discussions.append(thread)
	
	@property
	def id(self):
		if self._id:
			return(self._id)
		else:
			# TODO: Is it even possible?
			raise(NotImplementedException("Cannot retrieve category ID."))
	
	@property
	def name(self):
		if self._name:
			return(self._name)
		else:
			raise(NotImplementedExeption("Cannot retrieve category name."))
	
	@property
	def description(self):
		if self._description is not None:
			return(self._description)
		else:
			raise(NotImplementedExeption("Cannot retrieve category description."))
	
	@property
	def discussions(self):
		if self._discussions is not None:
			return(self._discussions)
		else:
			self.retrieve_discussions()
			return(self._discussions)
	
	threads = discussions
	
	def retrieve_discussions(self, pages=1):
		"""Obtains the discussion in the category. By default only retrieves the
		first page; more pages can be retrieved using the `pages` parameter.
		See `Session.get_discussions()` for more information."""
		
		page = self.session.get_page("index.php?CategoryID={0}".format(self.id))
		self._discussions = self.session.get_threads(page, pages=pages, category=self)
	
	def __eq__(self, other):
		if isinstance(other, Category):
			return(self.id == other.id)
		elif isinstance(other, int):
			return(self.id == other)
		elif isinstance(other, str):
			return(self.name == other)
		else:
			return(False)
	
	def __ne__(self, other):
		return(not self.__eq__(other))
	
	def __str__(self):
		return(self.name)
	
	def __int__(self):
		return(self.id)
	
	def __repr__(self):
		return("<vanilla.Category {self.id} \"{self.name}\">".format(self=self))

class PreparedComment(forumapi.PreparedPost):
	"""A post that's ready to be made in a thread. It has a `.message`
	property."""
	
	@property
	def message(self):
		return(self.post_data["Body"])
	
	@message.setter
	def message(self, value):
		self.post_data["Body"] = value
	
	def submit(self):
		"""Submits the post."""
		
		if self.posted:
			raise(forumapi.DuplicateError("Post already submitted."))
		
		result = self.post_data.submit()
		
		self.posted = True
		
		response_url = result.response.geturl()
		
		try:
			if self.type == "postreply":
				fragment = urllib.parse.urlparse(response_url).fragment
				comment_id = int(re.match("Comment_(\d+)", fragment).group(1))
				
				comment = Comment(self.session, comment_id, self.thread, content=self.post_data["Body"], user=self.session.user)
				
				if self.thread:
					self.thread._latest_comment = comment
				
				return(comment)
			elif self.type == "editpost":
				self.post._content = self.post_data["Body"]
		except Exception:
			# TODO: It may be possible that the post got denied because the
			# user doesn't have permissions (raise a PermissionError), or
			# because the message was too short (raise an InputError?)
			raise(forumapi.UnknownError("I have no idea.\n\n" + response_url))
	
	def __repr__(self):
		return("<vanilla.PreparedComment \"{self.type}\"{0}{1}>".format(" posted" if self.posted else "", " for {0}{1}".format(*self.identifier) if self.identifier else "", self=self))
